Repository navigation
feat(rpc): serve states/{id}/fork, config/deposit_contract and validator/duties/sync - #642
Conversation
🤖 Claude Code ReviewReview: PR 642 (sync duties,
|
🤖 Codex Code ReviewLooks good overall. The added endpoints are small, well-scoped, and the sync-duty implementation is efficient enough for the stated constraints. I did not spot consensus-critical bugs in fork choice, finalization, state transition, XMSS, or SSZ paths from this diff.
One small maintainability/perf note:
No security concerns jumped out from this PR. Automated review by OpenAI Codex · gpt-5.4 · custom prompt |
🤖 Kimi Code ReviewI'll review this PR systematically across correctness, security, performance, and Rust best practices. Overall AssessmentThis PR adds three Beacon API endpoints ( Detailed Findings1. Critical: Integer Overflow Risk in
|
| Priority | Item | File | Line |
|---|---|---|---|
| High | Overflow risk in period multiplication | validator.rs |
~1004 |
| Medium | Potential DoS via unbounded indices |
validator.rs |
72-76 |
| Medium | Inefficient eager HashMap construction | validator.rs |
95-100 |
| Low | Implicit Default::default() in tests |
validator.rs |
multiple |
| Low | hex::encode(pubkey.0) fragility |
validator.rs |
983 |
| Low | Missing execution_optimistic test |
states.rs |
457-489 |
The PR is generally well-crafted and ready for merge after addressing the overflow risk (Item 1) and considering the DoS limit (Item 2).
Automated review by Kimi (Moonshot AI) · kimi-k2.6 · custom prompt
MegaRedHand
left a comment
There was a problem hiding this comment.
A few comments, nothing blocking. The period bound is the only behavior change I'd consider; the rest are small cleanups and tighter tests.
| let requested_period = compute_sync_committee_period(epoch); | ||
| let committee = if requested_period == head_period { | ||
| current | ||
| } else if requested_period == head_period + 1 { |
There was a problem hiding this comment.
Minor: the allowed periods are measured from the head state's slot, not the clock. When the head is still in period P but the clock is already in P+1 (an empty first slot of the new period, or a head a few slots behind), a request for P+2 is a valid "next period" request and gets a 400 here.
Lighthouse covers this by advancing a lagging head state to the current period before answering (duties_from_state_load in http_api/src/sync_committees.rs). Here that would mean advancing through fork_choice::checkpoint_state, as attestation_data already does, since P+2's committee only exists in the advanced state's next_sync_committee.
| for validator_index in indices { | ||
| let validator = state | ||
| .validator(validator_index) | ||
| .map_err(|_| ApiError::BadRequest("unknown validator index"))?; |
There was a problem hiding this comment.
Nit: a 400 for an index the state doesn't know matches Lighthouse, but attester_duties in this same file skips unknown indices and answers 200. Worth picking one behavior for both duty endpoints, so a validator client sending the same index set to both gets consistent answers?
| indices: &[String], | ||
| ) -> Result<serde_json::Value, ApiError> { | ||
| let epoch = parse_epoch(epoch)?; | ||
| let indices = indices |
There was a problem hiding this comment.
Nit: this parse block is the same as attester_duties', which collects into a HashSet and so also drops duplicates. Here ["3", "3"] returns validator 3's duty twice. A shared parse_indices helper next to parse_epoch would keep both in line.
|
|
||
| // One pass over the committee rather than one per requested validator: | ||
| // the committee stores pubkeys, so that is what a validator is matched by. | ||
| let mut positions: HashMap<BlsPubkey, Vec<String>> = HashMap::new(); |
There was a problem hiding this comment.
Nit: this allocates a String per seat and then clones the Vec for each duty. Storing Vec<u64> positions and serializing the field with serialize_with = "ethlambda_types::beacon::serde_helpers::quoted_u64_seq::serialize" (as the altair containers do) avoids the per-seat allocations; once the requested indices are deduplicated, remove instead of get + clone avoids the copy too.
| state; see `docs/spec_deviations.md`). A validator is matched by pubkey and | ||
| gets every seat it holds, since the committee is drawn with replacement; one | ||
| with no seat is left out. An unknown index is a `400`, and the endpoint is a | ||
| `503` while the node is syncing. This node serves no sync committee message |
There was a problem hiding this comment.
Minor: besides the message and contribution endpoints, POST /eth/v1/validator/sync_committee_subscriptions is also unserved. Once a validator client gets sync duties it starts calling it, so for a VC with a sync committee member the visible effect of this PR is more 404s (subscriptions per epoch, messages and contributions per slot) instead of one 404 on duties/sync. Worth listing the subscriptions gap here too.
|
|
||
| /// `GET /eth/v1/beacon/states/{state_id}/fork`: the `Fork` the state carries, | ||
| /// which is what a validator client builds its signing domains from. | ||
| async fn get_fork(Path(state_id): Path<String>, State(store): State<Store>) -> Response { |
There was a problem hiding this comment.
Nit: the load, error and execution_optimistic + finalized envelope here is the same as get_finality_checkpoints'. A small helper that takes a closure over the state (state_json(store, state_id, |state| data)) would keep the state sub-resources consistent as more are added.
| crate::json_response(serde_json::json!({ | ||
| "data": { | ||
| "chain_id": config.deposit_chain_id.to_string(), | ||
| "address": HexPrefixed(&config.deposit_contract_address).to_string(), |
There was a problem hiding this comment.
Nit: hex_string(config.deposit_contract_address), defined further down in this file, does the same thing.
| let response = app.oneshot(request).await.unwrap(); | ||
| let status = response.status(); | ||
| let body = response.into_body().collect().await.unwrap().to_bytes(); | ||
| (status, serde_json::from_slice(&body).unwrap_or_default()) |
There was a problem hiding this comment.
Test nit: unwrap_or_default() turns a non-JSON body into Null, and the 400 tests only check the status code, so they would still pass if a different 400 path fired (epoch parse, body rejection). Unwrapping the parse and asserting on json["message"] would pin each test to the check it names.
| } | ||
|
|
||
| #[tokio::test] | ||
| async fn the_current_period_reads_the_current_committee() { |
There was a problem hiding this comment.
Test gap: every current-period test asks for exactly the head's epoch. A case for a later epoch in the same period (for example head_epoch + 1) would catch a regression to comparing epochs instead of periods.
a7f7f83 to
b252fbc
Compare
…poser-duties-v2 #642 was squashed onto the current beacon-chain-integration, so this merge ran against the old base and both sides re-added #642's work. Git kept the `duties/sync` test module twice; the second copy is dropped. In docs/rpc.md the duties paragraph keeps this branch's `dependent_root` wording.
b252fbc to
49359e3
Compare
…eposit_contract and POST /eth/v1/validator/duties/sync/{epoch} from ethlambda beacon, three of the four Beacon API endpoints validator clients call that were missing. fork returns the state's own Fork through the same state_id resolution as the other state endpoints, so a state root is the same 404. deposit_contract returns the Config's deposit chain id and contract address. duties/sync reads the head state's current_sync_committee for an epoch in the head's own sync committee period and next_sync_committee for the next one, matches each requested validator by pubkey and returns every seat it holds (the committee is drawn with replacement), leaves out validators with no seat, and answers 400 for an unknown index or any other period and 503 while syncing. An earlier period is refused rather than answered from a historical state, recorded in docs/spec_deviations.md. compute_sync_committee_period is added to the altair helpers as validator.md defines it.
49359e3 to
f6c5793
Compare
MegaRedHand
left a comment
There was a problem hiding this comment.
Let's revisit these nits after merging everything. Some of the things I mentioned were already fixed in following PRs
…#634) Squash of feat/beacon-gloas at 1d667c4 (merge-base 35c074b). Adapted onto #626-#633 and main (#642-#677): - #632/#633's epoch single pass ported to the element accessors; fulu runs through the single pass, with the eth1-bridge gate on electra only. - DB_VERSION 6 (gloas Config 5 + #628's table). - compute_node_weights skips the boost outside the justified subtree (boost_reaches_the_descent), for #627's LiveChain prune; the boosted-finalized-block bound test is dropped and the differential tests skip pruned roots. - #628's set_unrealized_justification slot argument in the tests; get_forkchoice_store keeps the anchor block's slot. - #653's validator-by-id handler on iter_validators/balance.
Squash of feat/beacon-gloas-live at d1ef2fe (merge-base d91c6e5). Adapted onto #626-#638 and main (#642-#677): - 13 tables (#628's BeaconUnrealizedJustifications beside the gloas two). - #626's event bus, test stand-ins and BlockAnnouncement carry the gloas sidecar and the two gloas chain-actor messages. - The Beacon API's optimism readers (syncing, fork, validator-by-id, sync duties, require_validated) go through block_is_optimistic. - #643's liveness recorder ignores envelopes and payload attestations.
…ient (#668) Squash of feat/beacon-sync-committee at 07f0357 (merge-base 55adf1b). Adapted onto #643-#667: gloas block production packs an empty sync aggregate, #643's liveness recorder ignores sync committee messages, and #638's per-validator settings reach the sync duties. The #642 commit this branch carries is main's already.
## 🗒️ Description / Motivation
Merges `beacon-chain-integration` into `main`. This adds an **Ethereum
Beacon Chain** client (phase0 through fulu) to the ethlambda binary,
alongside the Lean consensus client:
- **`ethlambda beacon`**: a beacon node. It checkpoint-syncs, follows
the chain over gossip and req/resp, validates execution payloads through
an execution client's Engine API, and serves the Beacon API, including
the validator endpoints.
- **`ethlambda validator`**: a beacon validator client (attest,
aggregate, propose) that talks to any beacon node over the standard REST
API.
- **`ethlambda node`** (lean) keeps its behavior, apart from the items
under "Behavior changes for lean" below.
Both chains run on the same storage layer, P2P actor, RPC crate and
chain actor. `BeaconState` has a `Lean` variant holding lean's `State`,
and each shared handler dispatches on it once, at the top.
Each piece was reviewed as its own PR into the integration branch
(listed under "Related Issues / PRs"). This PR merges that branch.
## What Changed
| Area | Change |
|---|---|
| `ethlambda-types` (`beacon/`) | Containers for phase0 to fulu, presets
(compile-time `preset-minimal` feature), runtime fork config, and a
`Lean` variant on `ForkName` and `BeaconState` |
| `ethlambda-state-transition` (`beacon/`) | State transition, epoch
processing, fork upgrades, fork choice, BLS (`blst`), KZG (`c-kzg`),
gossip validation rules, DAS custody math |
| `ethlambda-ssz-tree` (**new**) | Persistent Merkle `List`/`Vector` for
the validator registry and balances, so consecutive states share
unchanged subtrees |
| `ethlambda-storage` | One `Store` for both chains. Beacon states are
stored as snapshots plus VCDIFF deltas and written on a background
thread. Adds a committee cache, and `db_version`/`chain`/`preset`
metadata that resume checks |
| `ethlambda-blockchain` | Beacon path in the chain actor
(`BlockChain::spawn_beacon`): import, holding blocks until their parent
or columns arrive, applying gossip aggregates to fork choice, block
production and aggregation |
| `ethlambda-engine` (**new**) | Engine API client with JWT auth:
`newPayload`, `forkchoiceUpdated`, `getPayload`, `getBlobs` |
| `ethlambda-p2p` | Beacon wire: blocks, aggregates, subnet attestations
and data columns are validated before relay;
`beacon_blocks_by_{range,root}/2` served and requested; range sync;
column custody and sampling; attestation subnet backbone; discv5 always
on for `beacon` |
| `ethlambda-rpc` | Beacon API under `/eth/v1` and `/eth/v2`: JSON by
default, SSZ on request. The router is picked by `Store::chain()`, so
`/lean/v0` and the Beacon API are never served together |
| `ethlambda-validator` (**new**) | Validator client: duties,
attestations, aggregation selection, proposals, keymanager API. It has
**no slashing protection** (recorded in `docs/spec_deviations.md`) |
| `bin/ethlambda` | `beacon` and `validator` sub-commands; `--network`
takes a built-in name (`mainnet`, `sepolia`, `hoodi`) or a directory of
published network files; beacon checkpoint sync; `benchmark import`
replays a real block corpus offline; `run_node` is one startup path for
both chains |
| CI / Makefile | One beacon spec-test job per preset, minimal-preset
check, `make test` split into `test-consensus` and `test-node` (one
runner's disk no longer fits both), benchmark-smoke and tooling jobs,
disk-cleanup action |
| Docs | New: `beacon_stf.md`, `beacon_wire.md`, `beacon_engine.md`,
`cli.md`. Updated: `data_storage.md`, `metrics.md`, `rpc.md`,
`checkpoint_sync.md`, `discovery.md`, `benchmarking.md`,
`spec_deviations.md`, README (beacon quickstart with ethrex) |
## Correctness / Behavior Guarantees
- **Separation:** beacon behavior lives in `beacon` modules that lean
code never reads, and those modules never read lean's. The shared crates
dispatch on the chain once per handler.
- **Lean state at a beacon accessor:** this panics, naming the function
it reached (`lean_state_unreachable` / `lean_fork_unreachable`), so the
caller never gets a wrong answer back. `ForkName::Lean` is left out of
`ForkName::ALL`, so neither `parse` nor a fork upgrade can reach it.
- **Spec conformance:** every consensus-specs `v1.6.1` fixture case
passes on both presets: 5705 mainnet and 40009 minimal, including 150
mainnet `fork_choice` cases. Gossip fixtures are pinned to
`v1.7.0-beta.1`.
- **Resume safety:** a data directory written by another chain, preset
or format version is refused at startup, before any state is read.
### ⚠️ Behavior changes for lean (`ethlambda node`)
- **Existing data directories are refused on resume.** A directory
written by `main` has no `db_version` key, so `Store::from_db_state`
returns `DbVersionMismatch { found: 0, expected: 4 }`. There is no
migration: an upgraded node needs a fresh data directory (or checkpoint
sync).
- `blst` and `c-kzg` are now dependencies of the lean binary too, since
the state-transition crate holds both chains' rules. They are not
feature-gated.
- libssz is a git dependency pinned by `rev`: the beacon containers need
`DerefMut`/`IndexMut` on `SszList` (lambdaclass/libssz#33), which no
crates.io release has yet.
- The `release-fast` profile (used for tests) now sets `debug-assertions
= true`.
- `P2P::spawn` now runs before the chain actor is spawned, so gossip
arriving in that short window is dropped.
- `/lean/v0/blocks/finalized` answers JSON when asked for it by name.
SSZ stays the default.
## Tests Added / Run
- **Beacon spec tests:** `make test-beacon` (or `test-beacon-mainnet` /
`test-beacon-minimal`). Each fixture case is a separately named test,
e.g. `cargo test -p ethlambda-state-transition --test beacon_spec_tests
--features beacon-spec-tests -- electra/attester_slashing`. CI runs one
job per preset.
- **`make test`** still needs no consensus-spec download: the beacon
spec target is behind the `beacon-spec-tests` feature, and the BLS/KZG
vector tests report as ignored without it.
- **Minimal preset:** the `Test minimal preset` CI job builds and runs
the `ethlambda-types` unit tests with `preset-minimal` on.
- **Benchmark smoke:** a CI job runs the `benchmark` sub-command end to
end.
- Unit and integration tests in every new crate (`ssz-tree`, `engine`,
`validator`) and in the beacon paths of storage, p2p, rpc and the chain
actor.
## Related Issues / PRs
<details>
<summary>PRs merged into the integration branch (reviewed on
<code>lambdaclass/ethlambda_private</code>)</summary>
**Types and state transition**
- lambdaclass/ethlambda_private#1 Beacon Chain containers under a
`beacon` namespace
- lambdaclass/ethlambda_private#2 Beacon Chain state transition, phase0
through fulu
- lambdaclass/ethlambda_private#37 keep the registry and balances in
persistent Merkle trees
**Follower, sync and storage**
- lambdaclass/ethlambda_private#3 follow mainnet gossip behind an
`ethlambda beacon` sub-command
- lambdaclass/ethlambda_private#6 unify the lean and beacon storage
layer
- lambdaclass/ethlambda_private#7 checkpoint sync for the beacon chain
- lambdaclass/ethlambda_private#8 serve beacon blocks by range and by
root
- lambdaclass/ethlambda_private#9 give the follower a chain, from the
anchor to the tip
- lambdaclass/ethlambda_private#11 fulu data availability sampling,
custody through the availability gate
- lambdaclass/ethlambda_private#13 validate execution payloads over the
Engine API
- lambdaclass/ethlambda_private#15 follow a network described by files
on disk
- lambdaclass/ethlambda_private#26 add sepolia and hoodi as built-in
networks
- lambdaclass/ethlambda_private#48, lambdaclass/ethlambda_private#52,
lambdaclass/ethlambda_private#53, lambdaclass/ethlambda_private#54: held
blocks, their columns and range batches
**P2P**
- lambdaclass/ethlambda_private#18 stop outbound req/resp requests
crossing protocols
- lambdaclass/ethlambda_private#23 rank dial candidates by custody
redundancy
- lambdaclass/ethlambda_private#28 count why peers drop us
- lambdaclass/ethlambda_private#38 validate beacon gossip before
propagating it
- lambdaclass/ethlambda_private#43 run every data column check in p2p
- lambdaclass/ethlambda_private#47 validate beacon aggregates and subnet
attestations before relaying
- lambdaclass/ethlambda_private#19 apply gossip aggregates to fork
choice, backbone two attestation subnets
**API and validator**
- lambdaclass/ethlambda_private#21 serve the Beacon API from `ethlambda
beacon`
- lambdaclass/ethlambda_private#44 report the preset and constants from
`/eth/v1/config/spec`
- lambdaclass/ethlambda_private#16 beacon-chain validator client
- lambdaclass/ethlambda_private#34 serve the validator API
- lambdaclass/ethlambda_private#40 aggregate and propose through
`ethlambda beacon`
- lambdaclass/ethlambda_private#56 pack other nodes' attestations into
produced blocks
**Performance and metrics**
- lambdaclass/ethlambda_private#10, lambdaclass/ethlambda_private#20,
lambdaclass/ethlambda_private#22, lambdaclass/ethlambda_private#24,
lambdaclass/ethlambda_private#33, lambdaclass/ethlambda_private#49:
registry scans, parallel pubkey validation, committee cache, background
state writes, BLS key validation
- lambdaclass/ethlambda_private#17, lambdaclass/ethlambda_private#31:
import timing by phase
- lambdaclass/ethlambda_private#25 benchmark block import by replaying a
real corpus
**CI**
- lambdaclass/ethlambda_private#12, lambdaclass/ethlambda_private#30,
lambdaclass/ethlambda_private#45: split lint/test jobs, benchmark smoke
job, runner selection
</details>
Open PRs based on `beacon-chain-integration`, **not** included here:
lambdaclass#626, lambdaclass#627, lambdaclass#629, lambdaclass#630, lambdaclass#632, lambdaclass#634, lambdaclass#636, lambdaclass#638, lambdaclass#642, lambdaclass#644 to lambdaclass#652,
lambdaclass#656, lambdaclass#658.
## ✅ Verification Checklist
- [ ] Ran `make fmt` (clean)
- [ ] Ran `make lint` (clippy with `-D warnings`, clean)
- [ ] Ran `make test` (`test-consensus` plus `test-node`, at
`release-fast`), all passing
- [ ] Ran `make test-beacon` (both presets), all passing
---------
Co-authored-by: Pablo Deymonnaz <pdeymon@fi.uba.ar>
Co-authored-by: Klaus Lungwitz <klaus.lungwitz@lambdaclass.com>
Co-authored-by: Klaus @ LambdaClass <18153834+klaus993@users.noreply.github.com>
Co-authored-by: Chase Wright <chase.wright@ethereum.org>
## 🗒️ Description / Motivation
`POST /eth/v1/validator/liveness/{epoch}` is what a validator client's
**doppelganger protection** calls before it signs anything: "did anyone
see these validators act in epoch E?" If the node answers 404:
- **Lighthouse's validator client** with doppelganger protection on
keeps its validators muted forever.
- **Prysm's** fails at startup.
This is the last of the four missing endpoints; the other three are in
lambdaclass#642, which this PR is stacked on.
The Beacon API leaves the answer to the node's own view ("network, chain
or API"), and clients differ:
| | Source of `is_live` | Current epoch |
|---|---|---|
| Prysm | State participation flags only | Late by however long
inclusion takes |
| Lighthouse | Its gossip, block and aggregator caches | Complete, but
relies on caches that on this node only cover the subnets it joined |
| **This PR** | Participation flags **OR** what this node observed |
Covered as soon as the node sees the validator act |
## What Changed
- **`crates/storage/src/liveness.rs` (new)**: `ObservedLiveness`, one
bitset per epoch for the newest three.
- An epoch older than the window is ignored.
- Indices are capped at `1 << 24`, so one epoch is at most 2 MiB. Every
writer passes a validated index anyway; mainnet is ~2.4M validators,
about 300 KB.
- **`crates/storage/src/store.rs`**: an `observed_liveness` field and
accessor, shared by every `Store` clone like `committee_cache`, because
the set has three writers and a reader that all already hold a `Store`.
- **Writers:**
- **`crates/net/p2p/src/beacon/verdict.rs`**: on `Accept`, an aggregate
marks its aggregator and every attester its signature verified; a subnet
attestation marks its attester.
- **`crates/blockchain/src/lib.rs`**: a successfully imported beacon
block marks its proposer. This is done at import, not at gossip
acceptance, so range-synced blocks and blocks published through this
node's API count too.
- **`crates/net/rpc/src/beacon/pool.rs`**: submissions through
`pool/attestations` and `aggregate_and_proofs`, since gossip never
delivers a node its own messages. The aggregate path now keeps the
attesting indices that `stateful_checks` already returned.
- **`crates/net/rpc/src/beacon/validator.rs`**: `post_liveness` /
`liveness`.
- **`docs/rpc.md`**: the route and the liveness semantics.
## Correctness / Behavior Guarantees
- **Nothing unverified counts.** Only `Accept`ed gossip and validated
API submissions are recorded; an `Ignore`, `Reject` or `Overloaded`
object marks nobody.
- **Participation is read from the head state** for its own epoch
(`current_epoch_participation`) and the one before
(`previous_epoch_participation`). Before altair there are no flags, so
only observations count.
- **Epoch window:** the *store clock's* previous, current and next
epoch, not the head state's. The next epoch is answered but always
`false`, because a doppelganger check at an epoch boundary can land on
it; Lighthouse accepts it too.
- **Errors:**
- An epoch outside the window is a 400.
- An unknown index is a 400, as in Prysm (Lighthouse answers `false`).
An index outside the registry is a client bug, better reported than
hidden.
- **503 while syncing**, as the spec lists; neither client returns it.
- **Lock cost.** Writers take a `Mutex` once per accepted gossip object,
to set bits. That includes every accepted attestation on the backbone
subnets, which don't touch the attestation pool: roughly a couple of
thousand short locks a slot on mainnet, against a reader that only runs
when a validator client asks.
## Tests Added / Run
- **`ObservedLiveness`:**
- `a_recorded_validator_is_live_in_that_epoch_only`
- `record_all_sets_every_index`
- `epochs_older_than_the_window_are_pruned`
- `an_epoch_below_the_window_is_not_recorded`
- `an_index_past_the_guard_is_ignored`
- `observed_liveness_is_shared_across_store_clones`
- **Endpoint** (state at epoch 5 with its store clock set to match):
- `a_participation_flag_makes_a_validator_live` (current and previous
epoch flags)
- `an_observed_validator_is_live_without_a_flag`
- `the_next_epoch_is_answered_and_nobody_is_live_in_it`
- `epochs_outside_the_window_are_a_400`
- `an_unknown_validator_is_a_400`
- `a_syncing_node_answers_503`
- **P2P writers:**
- `an_accepted_aggregate_marks_its_aggregator_and_attesters_live`
- `an_accepted_subnet_attestation_marks_its_attester_live`
- `an_object_that_was_not_accepted_marks_nobody_live`
- **RPC writers:**
- `a_published_attestation_marks_its_attester_live`
- `a_refused_attestation_marks_nobody_live`
- `a_published_aggregate_marks_its_aggregator_and_attesters_live`: the
aggregate is built on one node and submitted to a fresh one, so only the
aggregate itself can have marked its attesters.
- **Not covered by a unit test:** the proposer write on block import.
The chain actor's tests have no successful beacon import to extend,
since that needs a block passing the full state transition. It's one
line, exercised on a devnet.
- CI's minimal-preset commands pass locally.
- **Not yet run:** kurtosis with Lighthouse's validator client and
`--enable-doppelganger-protection`. Its validators should come online
after the doppelganger wait instead of staying muted.
## Related Issues / PRs
- Stacked on lambdaclass#642 (`fork`, `deposit_contract`, `duties/sync`); retarget
to `beacon-chain-integration` once lambdaclass#642 merges.
## ✅ Verification Checklist
- [x] Ran `make fmt` — clean
- [x] Ran `make lint` (clippy with `-D warnings`) — clean
- [x] Ran `make test` (`test-consensus` plus `test-node`, at
`release-fast`) — all passing (1966 passed, 0 failed, 26 ignored)
…r duties, for Lighthouse's validator client (lambdaclass#653) ## 🗒️ Description / Motivation Lighthouse's validator client (v8.2.3) can't run on ethlambda beacon, because it needs two endpoints this node doesn't serve: | Endpoint | Before | What Lighthouse's VC does on the 404 | |---|---|---| | `GET /eth/v1/beacon/states/{state_id}/validators/{validator_id}` | 404 (only `?id=` and `POST` served) | It resolves each key's index through this route. Without it, every validator is "inactive", so no attester, sync, liveness or proposer duty is ever requested | | `GET /eth/v2/validator/duties/proposer/{epoch}` | 404 (v1 served) | It asks for v2 only, with no fallback to v1, so "Failed to download proposer duties" every epoch and it never proposes | The first one blocks the end-to-end check lambdaclass#642 and lambdaclass#643 list as still to do: Lighthouse's validator client with `--enable-doppelganger-protection`. Its doppelganger service only calls `liveness` for validators it has indices for. Found on a kurtosis devnet with Lighthouse's validator client talking only to ethlambda beacon. The VC logged `Validator without index` for each of its 64 keys and `All validators inactive` every slot. ## What Changed - **`crates/net/rpc/src/beacon/states.rs`**: `GET /eth/v1/beacon/states/{state_id}/validators/{validator_id}`. It returns one registry entry as an object, by index or by pubkey: the same entry the list endpoint returns, without the array. - **`crates/net/rpc/src/beacon/validator.rs`**: `GET /eth/v2/validator/duties/proposer/{epoch}`. These are v1's duties with v2's `dependent_root`. v1 and v2 share one handler, parameterized by the dependent-root rule. - **`docs/rpc.md`**: both routes, and the v2 `dependent_root` rule. - **`tooling/kurtosis-validator/`**: - `ethlambda_validator.client: lighthouse` runs `lighthouse vc` in place of ethlambda's validator client, on the same derived keys and against the same beacon node list. - Lighthouse writes its definitions file into the validators directory, so it gets a writable copy of the keys. - `doppelganger: true` adds `--enable-doppelganger-protection`. - `lighthouse_extra_params` passes any other flags through. - `network_params_lighthouse_vc.yaml` runs it against ethlambda beacon alone, with doppelganger protection on. - **`bin/ethlambda/src/main.rs`**: `cargo fmt`. A merge of `beacon-chain-integration` left the module list out of order, which is what fails Lint on lambdaclass#642 and lambdaclass#643. ## Correctness / Behavior Guarantees - **`validators/{validator_id}`:** - `404` for a validator the state doesn't hold, `400` for a malformed id. - `state_id` resolves the same way as on every other state endpoint. - **v2 proposer duties:** - `dependent_root` is the block root at the slot before the **previous** epoch's first slot, since fulu fixes proposers an epoch ahead. Where that would underflow, it's the genesis block root. - `503` while syncing. - v1 is unchanged. ## Tests Added / Run - `validators/{validator_id}`: - `one_validator_by_pubkey_or_index_is_the_list_entry` - `one_unknown_validator_is_a_404` - `one_malformed_validator_id_is_a_400` - v2 proposer duties: - `v2_proposer_duties_are_v1_s` - `the_v2_dependent_root_is_the_block_before_the_previous_epoch` - `the_v2_dependent_root_is_genesis_where_it_would_underflow` - `v2_proposer_duties_answer_503_while_syncing` - `cargo test -p ethlambda-rpc --profile release-fast`: 136 passed, 0 failed. - **Kurtosis devnet, Lighthouse v8.2.3's validator client talking only to ethlambda beacon** (built from this branch). The setup: - 2× geth+lighthouse hold validators [0, 112); Lighthouse's VC holds [112, 128) with `--enable-doppelganger-protection`. With `network_params_lighthouse_vc.yaml`'s split, where the VC holds every key, the chain stops during the doppelganger wait (see below). - Lighthouse skips doppelganger for validators registered at genesis, so the VC was restarted at slot 40 to exercise it. | Check | Result | |---|---| | Index resolution (`validators/{validator_id}`) | ✅ all 16 resolved; before this PR: `Validator without index` for every key | | v2 proposer duties | ✅ `current_epoch_proposers: 7`; it proposed slots 3, 4, 29 and 30. `dependent_root` matches Lighthouse's BN for epochs 0 and 1 | | Attestations and aggregates through ethlambda | ✅ published | | `duties/sync` (lambdaclass#642) | ✅ `Validator in sync committee` for 112 and 114 | | Doppelganger via `liveness` (lambdaclass#643) | ✅ after the restart: `Listening for doppelgangers` (16), then `Found no doppelganger … further_checks_remaining: 0` and `Doppelganger detection complete: starting validator`. The validators unmuted at slot 128 (epoch 4) and attested at once. `liveness` answered `false` for the muted keys and `true` for a participant's | | Finality | ✅ justified 3, finalized 2 at epoch 4 | - **Found on the devnet, not fixed here:** - **Sync-committee endpoints missing.** The node doesn't serve `sync_committee_subscriptions`, `pool/sync_committees` or the contribution endpoints. Lighthouse logs `Unable to publish sync committee messages` and `No aggregate contribution found` (CRIT) every slot. This is the known gap lambdaclass#642 describes. - **Duties refused at each epoch boundary.** `duties/attester` and `duties/proposer` bound the requested epoch by the **head state's** epoch, not the clock's (`validator.rs`, from the validator API work). At an epoch's first slot, before its block arrives, the next epoch's duties are a 400 (`epoch is not within one epoch of the head state's`). Lighthouse retries next slot and gets them, but it logs an ERROR every epoch. Lighthouse's BN advances the state instead; its own VCs logged no such failure. - **Stall with the committed split.** `network_params_lighthouse_vc.yaml`'s split, where the VC holds all 128 keys, risks a stall: while doppelganger mutes the VC's validators, a run of more than 8 missed slots puts Lighthouse past its sync tolerance and every VC stops. An earlier run with a 64/64 split stopped at slot 55. - **Proposer refused at the slot boundary.** Lighthouse's VC asked to propose slot 29 a second time 3 ms before slot 30, once the block was already imported; ethlambda answered `400 slot is not after the head block`. Harmless: slashing protection would refuse the second signature too. ## Related Issues / PRs - Stacked on lambdaclass#643, which is stacked on lambdaclass#642. Retarget to `beacon-chain-integration` once those merge. - Unblocks the Lighthouse end-to-end check listed under "Not yet run" in lambdaclass#642 and lambdaclass#643. ## ✅ Verification Checklist - [x] Ran `make fmt` — clean - [x] Ran `make lint` (clippy with `-D warnings`) — clean - [ ] Ran `make test` (`test-consensus` plus `test-node`, at `release-fast`) — only `ethlambda-rpc` locally (136 passed), the only crate with code changes; the full suite runs in CI
🗒️ Description / Motivation
Validator clients call Beacon API endpoints that ethlambda beacon didn't serve. This PR adds three of the four:
GET /eth/v1/beacon/states/{state_id}/fork(ValidatorRequiredApi): used for fork detection and signing domains. Prysm's validator client calls it in its doppelganger check.GET /eth/v1/config/deposit_contract: network sanity checks and tooling. Prysm's validator client needs it for voluntary exits and its keymanager UI.POST /eth/v1/validator/duties/sync/{epoch}(ValidatorRequiredApi): a validator client's sync committee service. Without it, Lighthouse and Prysm log a warning and skip sync duties.The fourth,
POST /eth/v1/validator/liveness/{epoch}, comes in a separate PR, since it's the only one that needs new shared state.What Changed
crates/net/rpc/src/beacon/states.rs:get_fork. It resolvesstate_idwith the sameload()as the other state endpoints and returnsstate.fork(), plusexecution_optimisticandfinalized.crates/net/rpc/src/beacon/config.rs:get_deposit_contract. It returnsConfig::deposit_chain_idanddeposit_contract_address, the same values/config/specreports.crates/net/rpc/src/beacon/validator.rs:post_sync_duties/sync_duties.crates/blockchain/state_transition/src/beacon/helpers/altair.rs:compute_sync_committee_period, as altair'svalidator.mddefines it.docs/rpc.md, a note on sync duties, and a newdocs/spec_deviations.mdentry.Correctness / Behavior Guarantees
forkduties/synccurrent_sync_committee; the next period readsnext_sync_committee.data, as the spec says.duties/attesterandduties/proposerdon't return 503; that's unchanged here.Tests Added / Run
fork:the_fork_is_the_one_the_state_carriesthe_finalized_states_fork_is_marked_finalizeda_fork_by_state_root_is_a_404deposit_contract:the_deposit_contract_is_mainnetsduties/sync, on a fixture whose current committee is validators 0–7 and next committee validators 8–15, so reading the wrong one shows up:the_current_period_reads_the_current_committee(also covers a validator in neither committee, and every seat listed)the_next_period_reads_the_next_committeethe_period_after_next_is_a_400an_earlier_period_is_a_400an_unknown_validator_is_a_400a_syncing_node_answers_503compute_sync_committee_period:a_sync_committee_period_spans_its_epochs_and_no_morecargo test -p ethlambda-types --lib --features preset-minimaland the same forethlambda-state-transition).Related Issues / PRs
✅ Verification Checklist
make fmt— cleanmake lint(clippy with-D warnings) — cleanmake test(test-consensusplustest-node, atrelease-fast) — all passing (1948 passed, 0 failed, 26 ignored)